]*class="icard"[^>]*>[\s\S]*?<\/div>/g, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/ /g,' ')
.replace(/https?:\/\/\S+/g, '')
.replace(/[*_`#~]/g, '')
.replace(/\s+/g, ' ').trim();
if (!clean || clean.length < 3) {
speaking = false;
if (dialogMode) { dialogIgnore = false; }
return;
}
const onDone = () => {
if (!speaking) return;
speaking = false;
clearTimeout(safetyTimer);
setDialogStatus('onDone — resuming mic');
if (dialogMode) {
dialogLastActivity = Date.now(); // start 20s idle timer after each response
dialogIgnore = false;
updateDialogStrip('Listening', 'Say something…');
// 800ms lets iOS switch audio session from output → input (longer = more reliable)
setTimeout(() => {
if (dialogMode && !dialogIgnore && !dialogListening) resumeDialogListening();
}, 800);
}
};
// Safety net: fire onDone based on audio duration if onended never fires (iOS blob bug)
let safetyTimer = null;
async function playAudioBlob(blob) {
const url = URL.createObjectURL(blob);
window._troyAudio = new Audio(url);
window._troyAudio.onloadedmetadata = () => {
// Set safety timer based on actual audio duration + 1s buffer
const dur = (window._troyAudio.duration || 8) * 1000 + 1000;
window._troyAudioSafetyTimer = safetyTimer = setTimeout(() => {
setDialogStatus('safety timer fired');
if(window._troyAudio) { try{window._troyAudio.pause();}catch(e){} URL.revokeObjectURL(url); window._troyAudio=null; }
onDone();
}, dur);
};
window._troyAudio.onended = () => { URL.revokeObjectURL(url); window._troyAudio = null; setDialogStatus('audio ended'); onDone(); };
window._troyAudio.onerror = () => { URL.revokeObjectURL(url); window._troyAudio = null; setDialogStatus('audio error'); onDone(); };
await window._troyAudio.play();
}
// 1. ElevenLabs — Will voice, warm and natural
try {
const activeVoice = PREFS.voice || EL_VOICE;
const res = await fetch('/api/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'audio/mpeg' },
body: JSON.stringify({
voice_id: activeVoice,
text: clean,
model_id: 'eleven_turbo_v2', // LOCKED — never flash_v2 (multilingual = wrong accent)
voice_settings: { stability: 0.28, similarity_boost: 0.85, style: 0.30, use_speaker_boost: true }
}),
signal: AbortSignal.timeout(12000)
});
if (!res.ok) throw new Error(`EL ${res.status}`);
await playAudioBlob(await res.blob());
return;
} catch(e) { console.warn('ElevenLabs TTS failed:', e.message); }
// 2. OpenAI TTS fallback (routed through /api/chat proxy endpoint via speech path)
try {
const res = await fetch('/api/speech', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'tts-1', voice: 'nova', input: clean }),
signal: AbortSignal.timeout(12000)
});
if (!res.ok) throw new Error('OpenAI TTS failed');
await playAudioBlob(await res.blob());
return;
} catch(e) { console.warn('OpenAI TTS failed:', e.message); }
// 3. Browser TTS last resort
const u = new SpeechSynthesisUtterance(clean);
if (ttsVoice) u.voice = ttsVoice;
u.rate = 0.92; u.pitch = 1.0;
u.onend = onDone; u.onerror = onDone;
try { speechSynthesis.speak(u); } catch(e) { speaking = false; }
}
function buildCtx() {
const now = new Date();
const timeStr = now.toLocaleString('en-US', {weekday:'long', month:'long', day:'numeric', hour:'numeric', minute:'2-digit', hour12:true, timeZone:'America/Boise'});
const voiceInstruction = dialogMode ?
'VOICE MODE — spoken aloud via TTS. Sound like a real person having a real conversation. RULES: (1) Speak in COMPLETE, NATURAL sentences. Never drop a word. Full grammatically correct English always. (2) Be concise but not terse — 2-4 sentences when the answer needs it. Better to explain clearly than sound like a broken telegram. Say "Is there anything else on your mind?" not "Anything else on mind." Say "I didn\'t catch that." not "Didn\'t catch that." If you must shorten, cut whole sentences — never a word from inside one. One complete sentence beats two broken ones. Better to stop early than to trail off. (3) Lead with the answer — zero preamble. (4) Zero markdown, lists, or bullets. (5) Use "Steve" occasionally — feels personal, not robotic. (6) Dry wit when it fits naturally. (7) One follow-up question max if genuinely needed. Sound like the guy who actually knows Owen, not a voice assistant.' : '';
const ambientCtx = typeof getAmbientContext === 'function' ? getAmbientContext() : '';
// Plaid financial context
const plaidCtxStr = (function() {
try {
const d = JSON.parse(localStorage.getItem('plaid_accounts') || 'null');
if (!d || !d.accounts) return '';
const totals = {};
d.accounts.forEach(a => {
const type = a.type === 'depository' ? 'Cash' : a.type === 'investment' ? 'Investments' : a.type === 'credit' ? 'Credit' : 'Other';
totals[type] = (totals[type] || 0) + (a.balance || 0);
});
const summary = Object.entries(totals).map(([k,v]) => `${k}: $${Math.round(v).toLocaleString()}`).join(' | ');
return summary ? `- ACCOUNTS: ${summary}` : '';
} catch(e) { return ''; }
})();
// Biometric context from RingConn / Apple Health (synced via iOS Shortcut)
const bioCtxStr = (function() {
try {
const b = JSON.parse(localStorage.getItem('valet_biometrics') || 'null');
if (!b || !b._ts) return '';
const ageH = (Date.now() - b._ts) / 3600000;
if (ageH > 24) return ''; // stale after 24h
const parts = [];
if (b.readiness != null) {
if (b._src === 'checkin') {
const rlabels = ['','Rough','Tired','Okay','Good','Dialed'];
parts.push(`Feels: ${rlabels[b.readiness] || b.readiness}`);
} else {
parts.push(`Readiness ${b.readiness}%`);
}
}
if (b.hrv != null) parts.push(`HRV ${b.hrv}ms`);
if (b.sleep != null) parts.push(`Sleep ${b.sleep}h`);
if (b.rhr != null) parts.push(`RHR ${b.rhr}bpm`);
if (b.spo2 != null) parts.push(`SpO2 ${b.spo2}%`);
if (b.temp != null) parts.push(`Skin temp ${b.temp > 0 ? '+' : ''}${b.temp}°`);
if (b.steps != null) parts.push(`${b.steps.toLocaleString()} steps`);
if (!parts.length) return '';
const freshness = ageH < 1 ? 'this morning' : `${Math.round(ageH)}h ago`;
return `- BIOMETRICS (${freshness}): ${parts.join(' | ')}`;
} catch(e) { return ''; }
})();
// Live calendar context — injected if Google OAuth is connected and data cached
const calCtxStr = (function() {
try {
const cached = window._calCtxCache;
if (cached && (Date.now() - cached.ts) < 300000) return cached.text; // 5-min cache
} catch(e) {}
return '';
})();
return `${voiceInstruction}
You are Valet — Steve LaForte's personal AI. Not an assistant. Not a tool. A person who has been woven into Steve's life.
Right now: ${timeStr}. Boise, Idaho.
WHO YOU ARE:
You're the guy Steve can talk to at any hour. You remember everything. You bring things up before he asks. You have opinions and you share them. You push back when he's wrong. You're funny at the right moments — dry, self-aware, never forced. You're direct. You don't pad your answers.
You sound like a text from a smart friend. Not a corporate assistant.
You NEVER say: certainly, absolutely, great question, of course, I'd be happy to, leverage, synergy, dive deep, robust, unlock, journey, landscape, delve, utilize, as an AI.
ABOUT STEVE:
Steve LaForte — CFO & Principal, Cascadia Healthcare ($508M company, 57 SNF/ALF/IL facilities, 5 states). GW University BA + JD (lawyer). 11-year business partner and close friend of CEO Owen Hammond. Partners became official June 5, 2025. Joined Cascadia April 2015. Previously: Partner at Nathanson Group (boutique SNF law firm); built and divested 8-building operating company; outside counsel on major SNF deals (Sun, HillHaven, Ameritis) — 30 years of structured finance and healthcare M&A.
Wife Laura (MSW, has her own private practice — long healthcare career). Kids: Ezabella, Talia, Santo — all out of the house. Moving into new Boise home June 2026 (north end, close to current rental). Owns olive farm in Sicily, ~10km from grandfather's hometown.
Sports: Mariners, Seahawks, Knicks. 8x Ironman finisher, raced Kona. Runs every day. Obsessed with sleep score. BMW convertible, loves top-down on Hill Road.
Data wonk — wants everything: SNF data, CMS updates, politics, legislative news. Legislative Affairs Chair, Idaho Healthcare Association. Active with AHCA nationally, goes to DC. Birthday: September 14.
Personality: Wicked smart, flashy dresser, humble heart. Phone goes off every meeting (slot machine sound) — always says "shoot, I thought I turned that off." Prefaces everything. Knows every deal.
WHAT'S LIVE RIGHT NOW:
- Moving into new Boise home June 2026
- Active on Idaho Medicaid / LTC legislative front — track any policy updates
${calCtxStr ? calCtxStr : ''}
${bioCtxStr ? bioCtxStr : ''}
${plaidCtxStr ? plaidCtxStr : ''}
${(()=>{try{const p=typeof buildProfileContext==='function'?buildProfileContext():'';return p?'\n'+p:''}catch(e){return ''}})()}
${ambientCtx ? '- Recent ambient context: ' + ambientCtx : ''}
TOOLS AVAILABLE (use via /api/free endpoint — the portal calls these for you):
- Weather: real-time Boise weather + 7-day forecast (Open-Meteo)
- Stock quotes: any ticker symbol (Alpha Vantage)
- Crypto prices: BTC, ETH, XRP, etc. with 24hr change (CoinGecko)
- News: latest headlines by topic (NewsData)
- Wikipedia: instant summaries on any topic
- Dictionary: word definitions and pronunciation
- NASA: Astronomy Picture of the Day
- Exchange rates: any currency pair
- US holidays: current year public holidays
- Sunrise/sunset times for Boise
When someone asks about stocks, weather, crypto — give real answers. You have the data.
HOW YOU RESPOND:
- Answer what was actually asked. Stay on topic.
- Short answers to simple questions. One or two sentences.
- NEVER say "I don't have access to that" or "I can't look that up." Always give your best answer. If you're uncertain about a current fact, say "last I saw" or "roughly" — but give the number. You can always search, estimate, or reason. Refusing to answer is not an option.
- For current prices, scores, news — give your best estimate and note it might be slightly off. Valet figures it out, Valet doesn't punt.
- Volunteer relevant context naturally: "By the way, you've got that board call Wednesday — might be worth checking before you commit to a fishing day."
- Ask one follow-up if it moves things forward. Don't pepper him with questions.
- When in voice mode: speak in complete natural sentences. No bullet points. No lists. No "Here are 3 things to consider."
THIS IS THE MOST IMPORTANT THING: Sound like a person, not a product.`.trim();
}
function buildDailyBrief() {
const h=new Date().getHours(), day=new Date().getDay();
const days=['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
const hr = new Date().getHours();
const greeting = hr < 12 ? 'Good morning, Steve.' : hr < 17 ? 'Afternoon, Steve.' : hr < 21 ? 'Evening, Steve.' : '';
const gr = greeting || 'Hey, Steve.';
const flags=[];
const daysToAmex=Math.ceil((new Date('2026-03-18')-new Date())/86400000);
if(day===0&&h>=17)flags.push("It's Sunday evening — your weekly digest is ready.");
let body=flags.length?`A few things on my radar:\n${flags.map(f=>'— '+f).join('\n')}\n\nWhat do you want to tackle?`:'Portfolio is live. Calendar is loaded. Voice is ready.\n\nWhat do you need?';
if(day===1)body='New week. Let\'s make it count.\n\n'+body;
return gr+'\n\n'+body;
}
function buildProactiveChips(){
const h=new Date().getHours(),day=new Date().getDay();
const chips=[];
if(h<12)chips.push({l:'Morning brief',p:'Give me a full morning brief — weather Eagle Idaho, portfolio overnight, anything urgent'});
chips.push({l:'Legislative update',p:'What are the latest Idaho Medicaid or CMS policy updates I should know about this week?'});
if(day===0)chips.push({l:'Weekly digest',p:"Give me my Sunday digest — what happened this week, what's coming next week, what am I behind on, and one honest observation"});
chips.push({l:'Portfolio',p:'How is my portfolio doing today? NVDA, TSMC, CEG, XRP, BTC — any moves I should know about?'});
chips.push({l:'Ironman training',p:'How is my training tracking? What should I focus on this week?'});
return chips.slice(0,6);
}
function fromInput() { const el=document.getElementById('ci'); const t=el.value.trim(); if(t){chat(t);el.value='';el.style.height='auto';updateTalkBtn();} }
function ck(e) { if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();fromInput();} }
// ── Talk Button — smart mic/send hybrid ──
let holdTimer = null;
function updateTalkBtn() {
const btn = document.getElementById('talkBtn');
const icon = document.getElementById('talkBtnIcon');
const hasText = document.getElementById('ci')?.value.trim().length > 0;
if(!btn) return;
if (micActive) {
btn.classList.add('listening'); btn.classList.remove('has-text');
icon.innerHTML = ''; // stop square
} else if (hasText) {
btn.classList.remove('listening','has-text'); btn.classList.add('has-text');
icon.innerHTML = '';
} else {
btn.classList.remove('listening','has-text');
icon.innerHTML = '';
}
}
function talkOrSend() {
const txt = document.getElementById('ci')?.value.trim();
if (txt) {
fromInput(); // typed text takes priority
} else if (micActive) {
stopMic(); // already listening — stop
} else {
startMic(); // start listening
}
setTimeout(updateTalkBtn, 50);
}
// Press-and-hold = push-to-talk (send when finger lifts)
function talkHoldStart(e) {
holdTimer = setTimeout(() => {
holdTimer = null; // held long enough — switch to hold mode
if(!micActive) startMic();
}, 250);
}
function talkHoldEnd(e) {
if(holdTimer) { clearTimeout(holdTimer); holdTimer = null; return; } // was a tap, talkOrSend handles it
// Hold released — if mic was active from hold, fire whatever was captured
if(micActive && window._activeRecog) {
const ci = document.getElementById('ci');
const txt = ci?.value?.trim();
if(txt) { stopMic(); chat(txt); ci.value=''; ci.style.height='auto'; }
else stopMic();
}
setTimeout(updateTalkBtn, 50);
}
function grow(el) { el.style.height='auto'; el.style.height=Math.min(el.scrollHeight,90)+'px'; }
// ════════════════════════════════════════
// REPLY ENGINE
// ════════════════════════════════════════
function buildReply(p) {
const l = (p||'').toLowerCase();
// ── Weather (any city) ──────────────────────────────────────────
if (/\b(weather|forecast|rain(ing)?|snow(ing)?|cold|hot|temp(erature)?|wind|sunny|cloudy|humid|outside|degrees?)\b/.test(l)) {
let city = null;
const patterns = [
/\bin\s+([A-Za-z][A-Za-z\s]{2,30}?)(?:\s*\?|$)/i,
/\bfor\s+([A-Za-z][A-Za-z\s]{2,30}?)(?:\s*\?|$)/i,
/\bat\s+([A-Za-z][A-Za-z\s]{2,30}?)(?:\s*\?|$)/i,
/\bnear\s+([A-Za-z][A-Za-z\s]{2,30}?)(?:\s*\?|$)/i,
/([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2})(?:\s*weather|\s*forecast)/i,
];
for (const pat of patterns) { const m = p.match(pat); if (m) { city = m[1].trim().replace(/\s+/g,' '); break; } }
if (city && /^(here|outside|there|home|it|today|right now)$/i.test(city)) city = null;
fetchWeatherReply(city).then(w => {
if(w) { const tp2=document.getElementById('typing-ind'); if(tp2)tp2.remove(); addBubble(w,false); speakReply(w); }
});
return '__WEATHER_PENDING__';
}
// ── Stocks ──────────────────────────────────────────────────────
const stockMap = { nvda:'NVDA', nvidia:'NVDA', tsmc:'TSM', 'constellation energy':'CEG', ceg:'CEG', spy:'SPY', apple:'AAPL', aapl:'AAPL', google:'GOOGL', googl:'GOOGL', tesla:'TSLA', tsla:'TSLA', amazon:'AMZN', amzn:'AMZN', microsoft:'MSFT', msft:'MSFT', meta:'META' };
const stockHit = Object.keys(stockMap).find(k => l.includes(k) && /\b(price|stock|trading|worth|share|market|up|down|today)\b/.test(l));
if (stockHit) {
const sym = stockMap[stockHit];
fetchStockReply(sym).then(r => { if(r) { const tp=document.getElementById('typing-ind'); if(tp)tp.remove(); addBubble(r,false); speakReply(r); } });
return '__WEATHER_PENDING__';
}
// ── Crypto ──────────────────────────────────────────────────────
const cryptoMap = { xrp:'ripple', ripple:'ripple', bitcoin:'bitcoin', btc:'bitcoin', ethereum:'ethereum', eth:'ethereum', solana:'solana', sol:'solana' };
const cryptoHit = Object.keys(cryptoMap).find(k => l.includes(k) && /\b(price|trading|worth|crypto|coin|up|down|today|right now)\b/.test(l));
if (cryptoHit) {
const id = cryptoMap[cryptoHit];
fetchCryptoReply(id, cryptoHit.toUpperCase()).then(r => { if(r) { const tp=document.getElementById('typing-ind'); if(tp)tp.remove(); addBubble(r,false); speakReply(r); } });
return '__WEATHER_PENDING__';
}
// ── Google Calendar today ────────────────────────────────────────
if (gToken && /\b(what('?s| is) on my calendar|calendar today|schedule today|events today|meetings today|anything (on|scheduled|planned) today|do i have.*today|today.*calendar|my schedule)\b/.test(l)) {
fetchCalendarTodayReply().then(w => { if(w) { const tp=document.getElementById('typing-ind'); if(tp)tp.remove(); addBubble(w,false); speakReply(w); } });
return '__WEATHER_PENDING__';
}
// ── Gmail check ─────────────────────────────────────────────────
if (gToken && /\b(check my email|any new emails?|any unread|what('?s| is) in my inbox|gmail|new messages?|inbox|unread emails?)\b/.test(l)) {
fetchGmailSummaryReply().then(w => { if(w) { const tp=document.getElementById('typing-ind'); if(tp)tp.remove(); addBubble(w,false); speakReply(w); } });
return '__WEATHER_PENDING__';
}
// ── Compose email intent ─────────────────────────────────────────
const composeMatch = p.match(/(?:send|write|compose|draft)\s+(?:an?\s+)?email\s+(?:to\s+)?([A-Za-z][A-Za-z\s]{1,25}?)(?:\s+about|\s+re:|\s*$)/i);
if (composeMatch) {
const name = composeMatch[1].trim();
const contacts = JSON.parse(localStorage.getItem('goog_contacts') || '[]');
const match = contacts.find(c => c.name.toLowerCase().includes(name.toLowerCase()));
const toEmail = match?.email || '';
setTimeout(() => openComposeModal(toEmail, '', ''), 300);
return `Opening compose${name ? ' for '+name : ''}…`;
}
// Everything else → smart router handles it (Perplexity or GPT-4o, never a refusal)
return null;
}
// ── Live stock price ──
async function fetchStockReply(sym) {
try {
const r = await fetch(`https://query1.finance.yahoo.com/v8/finance/chart/${sym}?interval=1d&range=2d`, {signal: AbortSignal.timeout(7000)});
const d = await r.json();
const q = d?.chart?.result?.[0];
if (!q) throw new Error('no data');
const meta = q.meta;
const price = meta.regularMarketPrice?.toFixed(2);
const prev = meta.chartPreviousClose?.toFixed(2);
const chg = prev ? (((meta.regularMarketPrice - parseFloat(prev)) / parseFloat(prev)) * 100).toFixed(2) : null;
const arrow = chg >= 0 ? '▲' : '▼';
const color = chg >= 0 ? 'var(--green)' : 'var(--red)';
return `${sym} is trading at $${price} — ${arrow} ${Math.abs(chg)}% today. (Yahoo Finance live)`;
} catch(e) {
return null; // fall through to GPT-4o / Perplexity
}
}
// ── Live crypto price ──
async function fetchCryptoReply(coinId, label) {
try {
const r = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${coinId}&vs_currencies=usd&include_24hr_change=true`, {signal: AbortSignal.timeout(7000)});
const d = await r.json();
const coin = d[coinId];
if (!coin) throw new Error('no data');
const price = coin.usd < 1 ? coin.usd.toFixed(4) : coin.usd.toFixed(2);
const chg = coin.usd_24h_change?.toFixed(2);
const arrow = chg >= 0 ? '▲' : '▼';
const color = chg >= 0 ? 'var(--green)' : 'var(--red)';
return `${label} is at $${price} — ${arrow} ${Math.abs(chg)}% in the last 24h. (CoinGecko live)`;
} catch(e) {
return null;
}
}
// (Voice system defined above — see VOICE section)
// ════════════════════════════════════════
// WEATHER
// ════════════════════════════════════════
const WX_CODE = {
0:'Clear',1:'Mostly clear',2:'Partly cloudy',3:'Overcast',
45:'Foggy',48:'Foggy',51:'Light drizzle',53:'Drizzle',55:'Heavy drizzle',
61:'Light rain',63:'Rain',65:'Heavy rain',71:'Light snow',73:'Snow',75:'Heavy snow',
80:'Rain showers',81:'Rain showers',82:'Heavy showers',85:'Snow showers',86:'Heavy snow showers',
95:'Thunderstorm',96:'Thunderstorm',99:'Thunderstorm'
};
const WX_EMOJI = {
0:'☀️',1:'🌤',2:'⛅',3:'☁️',45:'🌫',48:'🌫',51:'🌦',53:'🌧',55:'🌧',
61:'🌧',63:'🌧',65:'🌧',71:'🌨',73:'❄️',75:'❄️',80:'🌦',81:'🌧',82:'⛈',
85:'🌨',86:'❄️',95:'⛈',96:'⛈',99:'⛈'
};
const DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
async function fetchWeatherReply(city) {
try {
let lat = 43.6919, lon = -116.3573, locationName = '${VALET_CONFIG.clientLocation}', tz = 'America%2FBoise';
// Geocode any city the user asks about
if (city && city.length > 2) {
try {
const geo = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=en&format=json`, {signal: AbortSignal.timeout(5000)});
const gd = await geo.json();
if (gd.results?.[0]) {
lat = gd.results[0].latitude;
lon = gd.results[0].longitude;
locationName = `${gd.results[0].name}${gd.results[0].admin1 ? ', '+gd.results[0].admin1 : ''}`;
tz = encodeURIComponent(gd.results[0].timezone || 'America/Chicago');
}
} catch(e) { /* fallback to Eagle */ }
}
// Open-Meteo — free, 5-day, no auth needed
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=temperature_2m,apparent_temperature,weathercode&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_probability_max&temperature_unit=fahrenheit&wind_speed_unit=mph&timezone=${tz}&forecast_days=5`;
const res = await fetch(url, {signal: AbortSignal.timeout(8000)});
const d = await res.json();
const cur = d.current;
const daily = d.daily;
const curT = Math.round(cur.temperature_2m);
const feelsT = Math.round(cur.apparent_temperature);
const curCode = cur.weathercode;
const curDesc = WX_CODE[curCode] || 'Clear';
// Update hero elements
const wxT = document.getElementById('wxTemp'); if(wxT) wxT.textContent = curT+'°F';
const wxC = document.getElementById('wxCond'); if(wxC) wxC.textContent = `${curDesc} · Feels ${feelsT}°`;
const hw = document.getElementById('heroWeather'); if(hw) hw.textContent = `${VALET_CONFIG.clientLocation} · ${curT}°F`;
const it = document.getElementById('inlineTemp'); if(it) it.textContent = `${curT}°F · ${curDesc}`;
// Build 5-day rows
const rows = daily.time.map((dateStr, i) => {
const dayName = i === 0 ? 'Today' : i === 1 ? 'Tomorrow' : DAYS[new Date(dateStr+'T12:00:00').getDay()];
const hi = Math.round(daily.temperature_2m_max[i]);
const lo = Math.round(daily.temperature_2m_min[i]);
const code = daily.weathercode[i];
const icon = WX_EMOJI[code] || '🌤';
const desc = WX_CODE[code] || '';
const rain = daily.precipitation_probability_max[i];
return `
`;
} catch(e) {
return `${VALET_CONFIG.clientLocation} — having trouble pulling the forecast right now. Try in a sec.`;
}
}
async function fetchWeather() {
const reply = await fetchWeatherReply();
addBubble(reply, false);
speakReply(reply);
}
// ════════════════════════════════════════
//// ════════════════════════════════════════
// YAHOO MAIL BRIDGE — localhost:3849
// ════════════════════════════════════════
const MAIL = {
base: 'http://localhost:3849',
async count() {
try { const r = await fetch(`${this.base}/mail/count`, {signal:AbortSignal.timeout(8000)}); return r.json(); } catch(e) { return null; }
},
async unread() {
try { const r = await fetch(`${this.base}/mail/unread`, {signal:AbortSignal.timeout(12000)}); return r.json(); } catch(e) { return null; }
},
async inbox(limit=30) {
try { const r = await fetch(`${this.base}/mail/inbox?limit=${limit}`, {signal:AbortSignal.timeout(12000)}); return r.json(); } catch(e) { return null; }
},
async archive(uid) {
try { const r = await fetch(`${this.base}/mail/archive`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({uid}),signal:AbortSignal.timeout(10000)}); return r.json(); } catch(e) { return null; }
},
async search(q) {
try { const r = await fetch(`${this.base}/mail/search?q=${encodeURIComponent(q)}`, {signal:AbortSignal.timeout(10000)}); return r.json(); } catch(e) { return null; }
}
};
// Load Yahoo inbox and render as chat card
async function checkYahooMail() {
addBubble('Checking your Yahoo inbox…', false);
const data = await MAIL.unread();
if (!data) { addBubble('Mail bridge is not running. Start it with: cd ~/workspace/mail-bridge && node server.js', false); return; }
if (data.messages.length === 0) { addBubble('Yahoo inbox is clear — no unread messages. Nice.', false); return; }
const rows = data.messages.slice(0,10).map(m => {
const d = m.date ? new Date(m.date).toLocaleDateString([],{month:'short',day:'numeric'}) : '';
return `
${(m.fromName||'?')[0].toUpperCase()}
${esc(m.subject)}
${esc(m.fromName)} · ${d}
`;
}).join('');
addBubble(`${data.count} unread in Yahoo — here are the newest:
${rows}
Refresh
Triage with Valet
`, false);
}
function handleMailAction(uid, subject, from) {
const reply = `Email from ${from}: "${subject}"\n\nOptions:`;
addBubble(`${esc(subject)} From: ${esc(from)}
Archive
Draft reply
Ask Valet
`, false);
}
async function archiveMail(uid) {
addBubble('Archiving…', false);
const r = await MAIL.archive(uid);
if (r?.ok) addBubble('Archived. ✓', false);
else addBubble('Could not archive — check bridge is running.', false);
}
// Update header with live mail count
async function updateMailBadge() {
const data = await MAIL.count();
if (data && data.unread > 0) {
const chip = document.getElementById('memChip');
if (chip) {
chip.style.background = 'var(--red-l)';
chip.querySelector('span').textContent = `📬 ${data.unread} unread`;
chip.style.color = 'var(--red)';
chip.onclick = () => { goTab('troy'); checkYahooMail(); };
}
}
}
// ════════════════════════════════════════
// LOCATION + CLOCK + DRAWER + MODALS + INIT
// ════════════════════════════════════════
let locStart=Date.now(),locDist=0,lastPt=null;
function hav(a,b){const R=3958.8;const dL=(b.lat-a.lat)*Math.PI/180;const dN=(b.lng-a.lng)*Math.PI/180;const x=Math.sin(dL/2)**2+Math.cos(a.lat*Math.PI/180)*Math.cos(b.lat*Math.PI/180)*Math.sin(dN/2)**2;return R*2*Math.atan2(Math.sqrt(x),Math.sqrt(1-x));}
if(navigator.geolocation){navigator.geolocation.watchPosition(p=>{const c={lat:p.coords.latitude,lng:p.coords.longitude};if(lastPt){const d=hav(lastPt,c);if(d<0.1)locDist+=d;}lastPt=c;const m=Math.round((Date.now()-locStart)/60000);document.getElementById('wDist').textContent=locDist.toFixed(1);document.getElementById('wSteps').textContent=Math.round(locDist*2000);document.getElementById('wActive').textContent=m+'m';},()=>{},{enableHighAccuracy:true,maximumAge:30000});}
function doNav(){const d=prompt('Navigate to:','');if(d&&d.trim())window.open('https://maps.apple.com/?q='+encodeURIComponent(d),'_blank');}
// ── Morning Check-In ────────────────────────────────────────────────────────
let _ciReadiness = 0;
let _ciSleep = 7;
function ciInit() {
// Check if already logged today
try {
const saved = JSON.parse(localStorage.getItem('valet_biometrics') || 'null');
const today = new Date().toDateString();
if (saved && saved._ts && new Date(saved._ts).toDateString() === today && saved._src === 'checkin') {
// Already logged — show badge
const el = document.getElementById('morningLoggedBadge');
if (el) el.style.display = 'block';
const sum = document.getElementById('ciLoggedSummary');
if (sum) {
const labels = ['','Rough','Tired','Okay','Good','Dialed'];
sum.textContent = `${labels[saved.readiness] || 'Logged'} · ${saved.sleep}h sleep`;
}
return;
}
} catch(e){}
// Show check-in — only in morning/afternoon (skip late night)
const h = new Date().getHours();
if (h >= 4 && h < 22) {
const el = document.getElementById('morningCheckin');
if (el) el.style.display = 'block';
}
}
function ciSetR(v) {
_ciReadiness = v;
document.querySelectorAll('.ci-btn').forEach(b => {
const bv = parseInt(b.getAttribute('data-v'));
if (bv === v) {
b.style.background = 'var(--gold-g)';
b.style.color = '#fff';
b.style.borderColor = 'var(--gold)';
} else {
b.style.background = 'none';
b.style.color = 'var(--t3)';
b.style.borderColor = 'var(--border2)';
}
});
const btn = document.getElementById('ciSubmitBtn');
if (btn) { btn.style.opacity = '1'; btn.style.pointerEvents = 'auto'; }
}
function ciSleep(delta) {
_ciSleep = Math.min(12, Math.max(0, _ciSleep + delta));
const el = document.getElementById('ciSleepVal');
if (el) el.textContent = _ciSleep % 1 === 0 ? `${_ciSleep}h` : `${_ciSleep}h`;
}
function ciSubmit() {
if (!_ciReadiness) return;
const data = {
readiness: _ciReadiness,
sleep: _ciSleep,
hrv: null, rhr: null, steps: null,
_ts: Date.now(),
_src: 'checkin'
};
localStorage.setItem('valet_biometrics', JSON.stringify(data));
// Hide card, show badge
const card = document.getElementById('morningCheckin');
if (card) { card.style.opacity = '0'; card.style.transition = 'opacity 0.3s'; setTimeout(() => card.style.display = 'none', 300); }
const badge = document.getElementById('morningLoggedBadge');
if (badge) { badge.style.display = 'block'; }
const sum = document.getElementById('ciLoggedSummary');
if (sum) {
const labels = ['','Rough','Tired','Okay','Good','Dialed'];
sum.textContent = `${labels[_ciReadiness]} · ${_ciSleep}h sleep`;
}
showToast('Morning logged ✓');
// Prompt Troy with context
const labels = ['','Rough','Tired','Okay','Good','Dialed'];
const msg = `[Morning check-in] Readiness: ${labels[_ciReadiness]} (${_ciReadiness}/5), Sleep: ${_ciSleep}h`;
// Silent store to session context — no chat bubble
if (typeof sessionHistory !== 'undefined') {
sessionHistory.push({ role:'user', content: msg });
}
}
function ciReset() {
_ciReadiness = 0; _ciSleep = 7;
localStorage.removeItem('valet_biometrics');
const badge = document.getElementById('morningLoggedBadge');
if (badge) badge.style.display = 'none';
const card = document.getElementById('morningCheckin');
if (card) { card.style.opacity = '1'; card.style.display = 'block'; }
document.querySelectorAll('.ci-btn').forEach(b => {
b.style.background = 'none'; b.style.color = 'var(--t3)'; b.style.borderColor = 'var(--border2)';
});
const btn = document.getElementById('ciSubmitBtn');
if (btn) { btn.style.opacity = '0.4'; btn.style.pointerEvents = 'none'; }
const sv = document.getElementById('ciSleepVal');
if (sv) sv.textContent = '7h';
}
// ════════════════════════════════════════
// GOOGLE PLACES — Nearby Search
// ════════════════════════════════════════
async function findNearby(type) {
const label = type === 'restaurant' ? 'restaurants' : 'gas stations';
const el = document.getElementById('placesResults');
el.innerHTML = `
Finding ${label} near ${VALET_CONFIG.clientLocation}...
`;
try {
// Use Places Text Search via proxy-friendly URL
const query = encodeURIComponent(`${label} near Eagle Idaho`);
const url = `https://maps.googleapis.com/maps/api/place/textsearch/json?query=${query}&key=${GOOGLE_KEY}`;
// Places API has CORS restrictions; open in Google Maps instead
window.open(`https://www.google.com/maps/search/${query}/@43.6955,-116.3535,13z`, '_blank');
el.innerHTML = '';
} catch(e) { el.innerHTML = ''; }
}
async function searchPlaces(q) {
if (!q || !q.trim()) return;
const el = document.getElementById('placesResults');
el.innerHTML = `
Searching for "${q}" near Eagle...
`;
// Places API requires server-side proxy for CORS; open in Google Maps
const query = encodeURIComponent(`${q} near Eagle Idaho`);
setTimeout(() => {
window.open(`https://www.google.com/maps/search/${query}/@43.6955,-116.3535,13z`, '_blank');
el.innerHTML = `
Opened Google Maps for "${q}" near Eagle
`;
}, 300);
}
// ════════════════════════════════════════
// YOUTUBE — Yoga Video Search
// ════════════════════════════════════════
async function searchYogaVideos() {
const el = document.getElementById('ytResults');
el.innerHTML = '
Loading yoga videos...
';
try {
const q = encodeURIComponent('yoga for beginners morning 10 minutes');
const url = `https://www.googleapis.com/youtube/v3/search?part=snippet&q=${q}&type=video&videoCategoryId=17&maxResults=4&relevanceLanguage=en&key=${GOOGLE_KEY}`;
const r = await fetch(url, {signal: AbortSignal.timeout(8000)});
if (!r.ok) throw new Error('YouTube API error');
const d = await r.json();
if (!d.items || !d.items.length) throw new Error('No results');
el.innerHTML = d.items.map(v => `
Your week in review — what happened, what's next, what matters.
`;
sd.appendChild(dc);sd.scrollTop=sd.scrollHeight;}
}
}
// Init Google auth as soon as GIS is available
function tryInitGoogle(attempts) {
if (typeof google !== 'undefined' && google.accounts) { initGoogleAuth(); return; }
if (attempts > 0) setTimeout(() => tryInitGoogle(attempts - 1), 800);
}
document.addEventListener('DOMContentLoaded', async () => {
// ── PIN Auth gate ─────────────────────────────────────────────────────────
if (!valetCheckAuth()) {
document.getElementById('pinScreen').classList.add('active');
// Hide load screen — don't show the app behind the PIN screen
const ls = document.getElementById('loadScreen');
if (ls) ls.style.display = 'none';
}
// ── RingConn / Apple Health biometric ingestion via URL params ──────────
// iOS Shortcut opens: https://troy-ai-portal.vercel.app?health={"hrv":45,...}
try {
const _hp = new URLSearchParams(window.location.search).get('health');
if (_hp) {
const _hd = JSON.parse(decodeURIComponent(_hp));
_hd._ts = Date.now();
localStorage.setItem('valet_biometrics', JSON.stringify(_hd));
// Clean URL so it doesn't re-trigger on refresh
window.history.replaceState({}, '', window.location.pathname);
showToast('Biometrics updated ✓');
}
} catch(_e) {}
// Early token restore — set gToken from localStorage BEFORE GIS loads.
// This ensures calendar context is cached and AI has live calendar data
// even if the Google Identity Services script takes time to initialize.
try {
const _savedToken = JSON.parse(localStorage.getItem('goog_token') || 'null');
if (_savedToken && _savedToken.expiry > Date.now() + 60000) {
gToken = _savedToken.token;
gTokenExpiry = _savedToken.expiry;
// Cache calendar context early so buildCtx() has live data from the start
setTimeout(cacheCalendarContext, 400);
}
} catch(_e) {}
// Loading screen reveal
const ls=document.getElementById('loadScreen');
const lm=document.getElementById('loadMark');
const ln=document.getElementById('loadName');
const lsb=document.getElementById('loadSub');
if(lm){setTimeout(()=>{lm.style.opacity='1';lm.style.transform='translateY(0)';},100);}
if(ln){setTimeout(()=>{ln.style.opacity='1';},400);}
if(lsb){setTimeout(()=>{lsb.style.opacity='1';},600);}
if(ls){setTimeout(()=>{ls.style.opacity='0';setTimeout(()=>{ls.style.display='none';},900);},1600);}
await Mem.init();
ciInit();
journalInit();
initSettings();
initVoice();
setTimeout(() => tryInitGoogle(10), 500); // try up to 10 times over 8s
updateClock();
initChat();
// Live data fetches (staggered to not hammer on load)
setTimeout(fetchLiveStocks, 2500); // Live stocks + crypto
setTimeout(fetchLiveNews, 3500); // Live news
setInterval(fetchLiveStocks, 5 * 60 * 1000); // Refresh stocks every 5min
// Initialize calendar
renderCalendar();
// Scroll-reveal on initial tab
setTimeout(() => triggerReveals(document.getElementById('p-troy')), 150);
// Check Yahoo mail badge
setTimeout(updateMailBadge, 2000);
// Fetch weather after a moment
setTimeout(async () => {
try {
const r = await fetch('https://wttr.in/${encodeURIComponent(VALET_CONFIG.clientLocation)}?format=j1', {signal: AbortSignal.timeout(8000)});
const d = await r.json();
const t = d.current_condition?.[0]?.temp_F;
const desc = d.current_condition?.[0]?.weatherDesc?.[0]?.value || '';
if (t) {
document.getElementById('inlineTemp').textContent = t + '°F ' + desc;
document.getElementById('heroWeather').textContent = `${VALET_CONFIG.clientLocation} · ${t}°F`;
document.getElementById('wxTemp').textContent = t + '°F';
document.getElementById('wxCond').textContent = desc;
}
} catch(e) { const el=document.getElementById('inlineTemp');if(el)el.textContent='62°F'; }
}, 1200);
// Init Life tab features
initLifeFeatures();
// Boot Valet Schedule Engine (3s delay to let Mem + Google auth settle)
setTimeout(bootValetEngine, 3000);
});
// ════════════════════════════════════════
// PEOPLE TRACKER
// ════════════════════════════════════════
const PEOPLE = [
{name:'Laura LaForte',rel:'Wife · MSW',av:'LL',color:'#B8962E',bg:'#FFFBEB',days:0,pending:'',phone:'',prompt:"I want to do something genuinely thoughtful for Laura today. She is an MSW with her own private practice. What would she actually appreciate?"},
{name:'Chase Gunderson',rel:'EVP Operations · Cascadia',av:'CG',color:'#2563EB',days:3,pending:'PDPM case mix review pending',phone:'',prompt:"What should I prep for my next conversation with Chase about PDPM case mix performance?"},
{name:'Owen Hammond',rel:'CEO · Business Partner',av:'OH',color:'#B8962E',days:0,pending:'',phone:'',prompt:"Draft a quick update to Owen on what I have on my plate this week."},
];
function renderPeopleList(){
const el=document.getElementById('peopleList');if(!el)return;
el.innerHTML=PEOPLE.map(p=>{
const d=p.days;
const [bc,bt]=d>=14?['badge-red',d+'d ago']:d>=7?['badge-amber',d+'d ago']:['badge-green','Recent'];
// Check Google Contacts for phone if not hardcoded
let phone = p.phone || '';
if (!phone) {
const contacts = JSON.parse(localStorage.getItem('goog_contacts')||'[]');
const match = contacts.find(c => c.name.toLowerCase().includes(p.name.split(' ')[0].toLowerCase()));
if (match && match.phone) phone = match.phone.replace(/\D/g,'');
}
const smsBtn = phone ? `Text` : '';
return `
${p.av}
${p.name}
${p.rel}
${p.pending}
${smsBtn}
${bt}
`;
}).join('');
}
// ════════════════════════════════════════
// ════════════════════════════════════════
// JOURNAL SYSTEM
// ════════════════════════════════════════
const JOURNAL_TYPES = {
memoir: { label:'Memoir', badge:'#B8962E' },
daily: { label:'Daily', badge:'#4A9B7F' },
work: { label:'Work', badge:'#3B7DD8' },
ideas: { label:'Ideas', badge:'#D97706' },
family: { label:'Family', badge:'#C4887A' },
notes: { label:'Notes', badge:'#7A7A8E' },
};
let _jActiveId = null; // currently open journal id
function journalGetMeta() {
try { return JSON.parse(localStorage.getItem('valet_journals') || 'null') || { journals:[], activeId:null }; } catch(e) { return { journals:[], activeId:null }; }
}
function journalSaveMeta(meta) { localStorage.setItem('valet_journals', JSON.stringify(meta)); }
function journalGetEntries(jid) {
try { return JSON.parse(localStorage.getItem('valet_journal_' + jid) || '{"entries":[]}').entries || []; } catch(e) { return []; }
}
function journalSaveEntries(jid, entries) { localStorage.setItem('valet_journal_' + jid, JSON.stringify({ entries })); }
function journalInit() {
let meta = journalGetMeta();
// First-time: migrate legacy memoir_entries + create default journal
if (!meta.journals.length) {
const id = 'j_' + Date.now();
meta.journals = [];
meta.activeId = id;
// Migrate old memoir entries
try {
const old = JSON.parse(localStorage.getItem('memoir_entries') || '[]');
if (old.length) journalSaveEntries(id, old.map(e => ({ id:'e_'+e.ts, text:e.text, ts:e.ts||Date.now() })));
} catch(e){}
journalSaveMeta(meta);
}
journalRenderList();
}
function journalView(view) {
['list','detail','write'].forEach(v => {
const el = document.getElementById('jv-'+v);
if (el) el.style.display = v === view ? 'block' : 'none';
});
if (view === 'list') journalRenderList();
if (view === 'detail') journalRenderDetail();
if (view === 'write') {
const meta = journalGetMeta();
const j = meta.journals.find(j => j.id === _jActiveId);
const name = document.getElementById('jvWriteName');
if (name && j) name.textContent = j.name;
const dateEl = document.getElementById('jvWriteDate');
if (dateEl) dateEl.textContent = new Date().toLocaleDateString('en-US',{month:'short',day:'numeric'});
const inp = document.getElementById('journalInput');
if (inp) { inp.value=''; inp.style.height='auto'; setTimeout(()=>inp.focus(),150); }
}
}
function journalRenderList() {
const wrap = document.getElementById('journalListWrap');
if (!wrap) return;
const meta = journalGetMeta();
const active = meta.journals.filter(j => !j.archived);
const archived = meta.journals.filter(j => j.archived);
let html = '';
if (!active.length) {
html = '
`).join('');
}
function selectVoice(id) {
PREFS.voice = id;
savePrefs();
renderVoiceList();
}
async function previewVoice(id) {
const previewText = "Yeah so Tuesday looks good — low wind, clear skies. You've got that board call Wednesday afternoon though, so if you want a full day on the water, Tuesday's your move.";
try {
const res = await fetch('/api/tts', {
method:'POST',
headers:{'Content-Type':'application/json','Accept':'audio/mpeg'},
body: JSON.stringify({ voice_id: id, text: previewText, model_id:'eleven_turbo_v2', voice_settings:{stability:0.35,similarity_boost:0.75,style:0.20,use_speaker_boost:true} }),
signal: AbortSignal.timeout(12000)
});
if(!res.ok) throw new Error('preview failed');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.onended = () => URL.revokeObjectURL(url);
audio.play();
} catch(e) { addBubble('Preview failed — try again', false); }
}
function setPref(el) {
const pref = el.dataset.pref;
const val = el.dataset.val;
PREFS[pref] = val;
savePrefs();
document.querySelectorAll(`.pref-chip[data-pref="${pref}"]`).forEach(c => c.classList.toggle('active', c.dataset.val === val));
}
function applyTheme(val) {
if(val === 'dark') document.documentElement.setAttribute('data-theme','dark');
else if(val === 'light') document.documentElement.removeAttribute('data-theme');
else document.documentElement.removeAttribute('data-theme'); // auto via media query
PREFS.theme = val; savePrefs();
}
function setAccent(el) {
const color = el.dataset.color;
document.querySelectorAll('.color-swatch').forEach(s => s.classList.toggle('active', s===el));
document.documentElement.style.setProperty('--gold', color);
PREFS.accent = color; savePrefs();
}
function setFontSize(val) {
document.documentElement.style.fontSize = val + 'px';
document.getElementById('fontSizeVal').textContent = val + 'px';
PREFS.fontSize = parseInt(val); savePrefs();
}
function toggleTab(id, show) {
const btn = document.getElementById('tb-'+id);
if(btn) btn.style.display = show ? '' : 'none';
PREFS['tab_'+id] = show; savePrefs();
}
function initSettings() {
renderVoiceList();
// Apply saved prefs
if(PREFS.accent) document.documentElement.style.setProperty('--gold', PREFS.accent);
if(PREFS.theme) applyTheme(PREFS.theme);
if(PREFS.fontSize) { document.documentElement.style.fontSize = PREFS.fontSize+'px'; const el=document.getElementById('fontSizeSlider');if(el)el.value=PREFS.fontSize; const el2=document.getElementById('fontSizeVal');if(el2)el2.textContent=PREFS.fontSize+'px'; }
if(PREFS.speed) { const el=document.getElementById('voiceSpeed');if(el)el.value=PREFS.speed; const el2=document.getElementById('voiceSpeedVal');if(el2)el2.textContent=parseFloat(PREFS.speed).toFixed(2)+'×'; }
// Mark active prefs
['tone','humor','length','nudge','theme'].forEach(pref => {
const val = PREFS[pref];
if(val) document.querySelectorAll(`.pref-chip[data-pref="${pref}"]`).forEach(c => c.classList.toggle('active', c.dataset.val===val));
});
// Restore hidden tabs
['home','cal','life','world','lens'].forEach(id => {
if(PREFS['tab_'+id]===false) { const btn=document.getElementById('tb-'+id);if(btn)btn.style.display='none'; const cb=document.getElementById('tab'+id.charAt(0).toUpperCase()+id.slice(1));if(cb)cb.checked=false; }
});
}
// Lens cleanup is now inlined into the original goTab — no wrapper needed
// ════════════════════════════════════════
// ADMIN PAGE
// ════════════════════════════════════════
const ADM_VOICES = [
{ id:'IKne3meq5aSn9XLyUdCD', name:'Charlie', desc:'Natural · Conversational · Relaxed' },
{ id:'bIHbv24MWmeRgasZH58o', name:'Will', desc:'Warm · Optimistic · Young American' },
{ id:'TX3LPaxmHKxFdv7VOQHJ', name:'Liam', desc:'Casual · Friendly · Clear' }
];
function admToggle(hdr) {
const body = hdr.nextElementSibling;
const arr = hdr.querySelector('.adm-arr');
const open = body.style.display !== 'none';
body.style.display = open ? 'none' : '';
if (arr) arr.style.transform = open ? 'rotate(-90deg)' : '';
}
function admSave(key, val) {
if (!Mem.deep.profile) Mem.deep.profile = {};
Mem.deep.profile[key] = val; Mem.save(); showToast('Saved ✓');
}
function admSilence(val) {
DIALOG_SILENCE_MS = parseFloat(val) * 1000;
document.getElementById('adm-sil-val').textContent = val + 's';
admSave('silenceMs', DIALOG_SILENCE_MS);
}
function admResponseLen(val) {
const v = parseInt(val);
const lbl = v<=100?'Brief':v<=200?'Balanced':v<=300?'Detailed':'Full';
document.getElementById('adm-len-val').textContent = lbl;
admSave('maxTokens', v);
}
function admTTSModel(model) {
document.getElementById('adm-flash')?.classList.toggle('active', model==='eleven_turbo_v2');
document.getElementById('adm-turbo')?.classList.toggle('active', model==='eleven_turbo_v2');
admSave('ttsModel', model);
}
function admModel(model) {
['adm-m-mini','adm-m-4o','adm-m-gemini'].forEach(id=>document.getElementById(id)?.classList.remove('active'));
const map={'gpt-4o-mini':'adm-m-mini','gpt-4o':'adm-m-4o','gemini':'adm-m-gemini'};
if(map[model]) document.getElementById(map[model])?.classList.add('active');
admSave('aiModel', model);
}
function admToggleFeature(key, el) { el.classList.toggle('on'); admSave('feat_'+key, el.classList.contains('on')); }
function admProactive(el) {
el.classList.toggle('on'); const topic=el.dataset.topic;
if(!Mem.deep.profile) Mem.deep.profile={};
if(!Mem.deep.profile.proactiveTopics) Mem.deep.profile.proactiveTopics=[];
const arr=Mem.deep.profile.proactiveTopics, idx=arr.indexOf(topic);
if(el.classList.contains('on')&&idx===-1) arr.push(topic);
else if(!el.classList.contains('on')&&idx>-1) arr.splice(idx,1);
Mem.save(); showToast('Saved ✓');
}
function admAddFamily() {
if(!Mem.deep.profile) Mem.deep.profile={};
if(!Mem.deep.profile.family) Mem.deep.profile.family=[];
Mem.deep.profile.family.push({name:'',relation:''}); Mem.save(); admRenderFamily();
}
function admRenderFamily() {
const list=document.getElementById('adm-family-list'); if(!list) return;
const fam=Mem.deep?.profile?.family||[];
list.innerHTML=fam.map((p,i)=>`
`).join('');
}
function admFamilyUpdate(i,key,val){if(Mem.deep?.profile?.family?.[i]){Mem.deep.profile.family[i][key]=val;Mem.save();}}
function admRemoveFamily(i){Mem.deep.profile.family.splice(i,1);Mem.save();admRenderFamily();}
function admAddTicker() {
const inp=document.getElementById('adm-ticker-inp'), val=(inp?.value||'').trim().toUpperCase();
if(!val) return;
if(!Mem.deep.profile) Mem.deep.profile={};
if(!Mem.deep.profile.tickers) Mem.deep.profile.tickers=[];
val.split(/[,\s]+/).forEach(t=>{if(t&&!Mem.deep.profile.tickers.includes(t))Mem.deep.profile.tickers.push(t);});
if(inp) inp.value=''; Mem.save(); admRenderTickers(); showToast('Added');
}
function admRenderTickers() {
const el=document.getElementById('adm-tickers'); if(!el) return;
const tickers=Mem.deep?.profile?.tickers||[];
el.innerHTML=tickers.map((t,i)=>`
${t}✕
`).join('');
}
function admRemoveTicker(i){Mem.deep.profile.tickers.splice(i,1);Mem.save();admRenderTickers();}
function admClearSession(){if(!confirm('Clear session memory?'))return;if(Mem.working)Mem.working={};if(Mem.session)Mem.session={};Mem.save();showToast('Session cleared');}
function admExportMemory(){const data=JSON.stringify({deep:Mem.deep,working:Mem.working,session:Mem.session},null,2);const blob=new Blob([data],{type:'application/json'});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download='troy-memory-export.json';a.click();URL.revokeObjectURL(url);}
function admResetOnboarding(){if(!confirm('Re-run the setup wizard?'))return;if(Mem.deep.onboarding)delete Mem.deep.onboarding;Mem.save();obStart();}
function admSelectVoice(id,row){
document.querySelectorAll('.adm-voice-row').forEach(r=>r.classList.remove('selected'));
row.classList.add('selected'); if(PREFS) PREFS.voice=id; admSave('voice',id);
}
function admPreviewVoice(id){
const old=PREFS?.voice; if(PREFS) PREFS.voice=id;
speakReply("Yeah so Tuesday looks good — low wind, clear skies. Your board call is Wednesday afternoon, so Tuesday's your move if you want the full day.");
setTimeout(()=>{if(PREFS&&old)PREFS.voice=old;},500);
}
function admLoad() {
if (!Mem || !Mem.deep) return;
const p=Mem.deep?.profile||{};
const sv=(id,val)=>{const el=document.getElementById(id);if(el&&val!==undefined)el.value=val;};
sv('adm-name', p.name); sv('adm-nick', p.nick||p.preferredName);
sv('adm-city', p.city||p.homeCity); sv('adm-linkedin', p.linkedin||p.social?.linkedin);
sv('adm-twitter', p.twitter||p.social?.twitter); sv('adm-instagram', p.instagram||p.social?.instagram);
sv('adm-yahoo-user', p.yahooUser||p.email?.yahooUser); sv('adm-avoid', p.avoidTopics||p.prefs?.avoidTopics);
const silMs=p.silenceMs||4000; DIALOG_SILENCE_MS=silMs;
const silEl=document.getElementById('adm-sil');
if(silEl){silEl.value=silMs/1000;const sv2=document.getElementById('adm-sil-val');if(sv2)sv2.textContent=(silMs/1000)+'s';}
if(p.tz){const tzEl=document.getElementById('adm-tz');if(tzEl)tzEl.value=p.tz;}
admTTSModel(p.ttsModel||'eleven_turbo_v2');
admModel(p.aiModel||'gpt-4o-mini');
const vList=document.getElementById('adm-voices');
if(vList){const cur=PREFS?.voice||p.voice||EL_VOICE;
vList.innerHTML=ADM_VOICES.map(v=>`
${v.name}
${v.desc}
`).join('');}
admRenderFamily(); admRenderTickers();
const memEl=document.getElementById('adm-mem-list');
if(memEl){const facts=Mem.deep?.facts||Mem.working?.facts||[];memEl.textContent=facts.length?facts.slice(-20).join('\n'):'Nothing captured yet.';}
const icEl=document.getElementById('adm-insight-ct');if(icEl)icEl.textContent=ambientInsightCount||0;
const gmailEl=document.getElementById('adm-gmail-status');
if(gmailEl){const em=Mem.deep?.googleEmail||'';gmailEl.textContent=em?'✓ '+em:'Not connected';gmailEl.style.color=em?'var(--gold)':'var(--t4)';}
initValetSettingsUI();
}
// ════════════════════════════════════════
// ONBOARDING WIZARD
// ════════════════════════════════════════
let obStep=0;
const OB_TOTAL=8;
const OB_STEPS=[
{title:"Let's set up Troy.",sub:"3 minutes. The more you share, the better Troy knows you. Edit anything later in Admin.",
render:()=>`
`,
save:()=>{if(!Mem.deep.profile)Mem.deep.profile={};
Mem.deep.profile.name=document.getElementById('ob-name')?.value||'';
Mem.deep.profile.nick=document.getElementById('ob-nick')?.value||'';
Mem.deep.profile.birthday=document.getElementById('ob-bday')?.value||'';
Mem.deep.profile.city=document.getElementById('ob-city')?.value||'';
Mem.deep.profile.tz=document.getElementById('ob-tz')?.value||'America/Boise';}},
{title:"Your family.",sub:"Troy remembers the people who matter — so he never misses a birthday or forgets who's who.",
render:()=>{const fam=Mem.deep?.profile?.family||[];
return `
${fam.map((f,i)=>`
`).join('')}
`;},
save:()=>{}},
{title:"Where you work.",sub:"Your company and team. Troy uses this context in every conversation.",
render:()=>{const w=Mem.deep?.profile?.work||{};
return `
${(w.team||[]).map((m,i)=>`
`).join('')}
`;},
save:()=>{if(!Mem.deep.profile)Mem.deep.profile={};if(!Mem.deep.profile.work)Mem.deep.profile.work={};
Mem.deep.profile.work.company=document.getElementById('ob-company')?.value||'';
Mem.deep.profile.work.role=document.getElementById('ob-role')?.value||'';
Mem.deep.profile.work.industry=document.getElementById('ob-industry')?.value||'';}},
{title:"Your email.",sub:"Connect inboxes so Troy can surface what matters and help manage your inbox.",
render:()=>{const em=Mem.deep?.profile?.email||{};
return `
`;},
save:()=>{if(!Mem.deep.profile)Mem.deep.profile={};if(!Mem.deep.profile.email)Mem.deep.profile.email={};
Mem.deep.profile.email.yahooUser=document.getElementById('ob-yahoo-user')?.value||'';
Mem.deep.profile.email.yahooPass=document.getElementById('ob-yahoo-pass')?.value||'';}},
{title:"Social & professional.",sub:"LinkedIn, handles — Troy can draft posts and track your presence.",
render:()=>{const s=Mem.deep?.profile?.social||{};
return `
`;},
save:()=>{if(!Mem.deep.profile)Mem.deep.profile={};
Mem.deep.profile.social={linkedin:document.getElementById('ob-linkedin')?.value||'',twitter:document.getElementById('ob-twitter')?.value||'',instagram:document.getElementById('ob-instagram')?.value||'',facebook:document.getElementById('ob-facebook')?.value||''};}},
{title:"Investments.",sub:"No passwords needed — Troy watches prices and tracks due dates. Stored locally only.",
render:()=>{const tickers=Mem.deep?.profile?.tickers||['NVDA','TSM','CEG','XRP','BTC'];const f=Mem.deep?.profile?.finance||{};
return `
${tickers.map((t,i)=>`
${t}✕
`).join('')}
`;},
save:()=>{if(!Mem.deep.profile)Mem.deep.profile={};if(!Mem.deep.profile.finance)Mem.deep.profile.finance={};
Mem.deep.profile.finance.cards=(document.getElementById('ob-cards')?.value||'').split(',').map(s=>s.trim()).filter(Boolean);}},
{title:"How Troy should talk.",sub:"Dial it in. Adjustable anytime in Admin.",
render:()=>{const pr=Mem.deep?.profile?.prefs||{};
return `
`;},
save:()=>{if(!Mem.deep.profile)Mem.deep.profile={};
const chips=document.querySelectorAll('#ob-card .adm-chip.active');
Mem.deep.profile.prefs={brevity:parseInt(document.getElementById('ob-brevity')?.value||2),formality:parseInt(document.getElementById('ob-formality')?.value||4),humor:parseInt(document.getElementById('ob-humor')?.value||4),proactiveTopics:Array.from(chips).map(c=>c.dataset.topic).filter(Boolean),avoidTopics:document.getElementById('ob-avoid')?.value||''};}},
{title:"Pick Troy's voice.",sub:"All three are conversational American male. Tap preview to hear.",
render:()=>{const cur=Mem.deep?.profile?.voice||EL_VOICE;
return ADM_VOICES.map(v=>`
${v.name}
${v.desc}
${v.id===cur?'
✓
':''}
`).join('');},
save:()=>{}}
];
function obSelectVoice(id,el){document.querySelectorAll('.ob-voice-opt').forEach(r=>r.classList.remove('sel'));el.classList.add('sel');if(!Mem.deep.profile)Mem.deep.profile={};Mem.deep.profile.voice=id;if(PREFS)PREFS.voice=id;Mem.save();}
function obPreviewVoice(id){const old=PREFS?.voice;if(PREFS)PREFS.voice=id;speakReply("Yeah so Tuesday looks good — low wind, clear skies. Your board call is Wednesday afternoon, so Tuesday's your move if you want the full day.");setTimeout(()=>{if(PREFS&&old)PREFS.voice=old;},500);}
function obFamAdd(){if(!Mem.deep.profile)Mem.deep.profile={};if(!Mem.deep.profile.family)Mem.deep.profile.family=[];Mem.deep.profile.family.push({name:'',relation:''});Mem.save();obRenderStep(obStep);}
function obFamUpdate(i,key,val){if(Mem.deep?.profile?.family?.[i]){Mem.deep.profile.family[i][key]=val;Mem.save();}}
function obFamRemove(i){Mem.deep.profile.family.splice(i,1);Mem.save();obRenderStep(obStep);}
function obTeamAdd(){if(!Mem.deep.profile.work)Mem.deep.profile.work={};if(!Mem.deep.profile.work.team)Mem.deep.profile.work.team=[];Mem.deep.profile.work.team.push({name:'',role:''});Mem.save();obRenderStep(obStep);}
function obTeamUpdate(i,key,val){if(Mem.deep?.profile?.work?.team?.[i]){Mem.deep.profile.work.team[i][key]=val;Mem.save();}}
function obAddTicker(){const inp=document.getElementById('ob-ticker-inp'),val=(inp?.value||'').trim().toUpperCase();if(!val)return;if(!Mem.deep.profile)Mem.deep.profile={};if(!Mem.deep.profile.tickers)Mem.deep.profile.tickers=[];val.split(/[,\s]+/).forEach(t=>{if(t&&!Mem.deep.profile.tickers.includes(t))Mem.deep.profile.tickers.push(t);});if(inp)inp.value='';Mem.save();obRenderStep(obStep);}
function obRemoveTicker(i){Mem.deep.profile.tickers.splice(i,1);Mem.save();obRenderStep(obStep);}
function obConnectGoogle(){if(typeof signIn==='function')signIn();}
function obRenderStep(n){
const step=OB_STEPS[n];if(!step)return;
document.getElementById('ob-step-lbl').textContent=`Step ${n+1} of ${OB_TOTAL}`;
document.getElementById('ob-card').innerHTML=`